Skip to content

/writing-tests skill + oxlint rule + test cleanup#45

Merged
kostyafarber merged 4 commits intomainfrom
firmclaw/writing-tests-skill
Apr 26, 2026
Merged

/writing-tests skill + oxlint rule + test cleanup#45
kostyafarber merged 4 commits intomainfrom
firmclaw/writing-tests-skill

Conversation

@kostyafarber
Copy link
Copy Markdown
Collaborator

Makes the testing conventions this repo has accumulated into a first-class, discoverable, and enforced thing.

Three commits

  1. Add /writing-tests skill; collapse Claude.md Testing to a pointer

    • .claude/skills/writing-tests/SKILL.md is now the source of truth: the rule ("assert on observable state, not mock calls"), when to stop and rethink (4-row table of banned patterns), the 4 test categories (Tool / Command / Pure module / Bridge), templates, the fake-test checklist, and why these rules exist (links to the original sweep commits).
    • Tells agents to research prior art online (VSCode, Obsidian, Signal Desktop, Bitwarden, tldraw) before inventing a mock — the SystemClipboard adapter in this repo came from exactly that pattern.
    • Claude.md Testing section collapses from 20 lines to a single sentence pointing at the skill.
  2. Add oxlint shift/no-vi-stub-global-in-tests; migrate EdgePanManager test

    • New lint rule: vi.stubGlobal in any .test.ts is an error. Catches the "I'll just monkey-patch window.electronAPI / requestAnimationFrame" reflex at CI, not at review.
    • EdgePanManager.test.ts was the one offender. Rewrote it through TestEditor: start a real hand-tool drag, trigger editor.updateEdgePan(...), assert on the observable viewport pan change. No rAF stub, no spy, no vi.fn. Two tests (pans during drag, no-op when not dragging).
  3. Rename SelectionManager.test.tstypes/selection.test.ts

    • Tested Selection from @/types/selection but lived in managers/ under the wrong name. Pure rename, no content changes. Per testing-strategy.md Ruler tool #4.

Out of scope (follow-up)

The skill's banned-pattern list covers more than just vi.stubGlobal. Five other test files still use vi.spyOn / toHaveBeenCalled / counter variables and would need migration or rewrite:

  • BaseTool.contract.test.ts — arguably legitimate (lifecycle contract)
  • signal.test.ts — legitimate (reactive library fire counts ARE the contract)
  • lifecycle.test.ts — EventEmitter; closure-capture rewrite is easy
  • layout.test.tspathHitTester spy; needs migration to observable output
  • KeyboardRouter.test.ts — needs inspection

Each has a different disposition (migrate / keep-and-exempt / delete). A follow-up PR should sweep these with a broader lint rule (no-mock-call-assertions) once the migrations are in.

Memory

Also updated feedback_no_spy_count_tests.md in personal memory to point at the skill instead of duplicating the content — skill is now the source of truth.

Test plan

  • pnpm test — 539 tests pass
  • pnpm lint:check — 0 errors, 0 warnings (106 rules, was 105)
  • pnpm typecheck — clean
  • Smoke-tested the lint rule: a vi.stubGlobal in a fresh .test.ts fires the expected error

The /writing-tests skill is the source of truth for how to write tests in
this codebase — how to pick a category, templates for each, the fake-test
checklist, and the banned patterns (counting invocations, hand-built
events, global stubs, parallel-world test harnesses). It also tells agents
to research prior art online (VSCode, Obsidian, Signal, Bitwarden) before
inventing a mock.

Claude.md Testing section shrinks to a single sentence pointing at the
skill, since the full guidance belongs in one place that's triggered when
tests are being written — not duplicated in the root constitution.
Ban vi.stubGlobal in .test.ts files via a new shift-plugin rule. Global
stubbing is order-sensitive, leaks across tests if unstub is missed, and
hides production code's unmanaged global dependencies — the skill's
"inject the boundary" rule catches it at review, this rule catches it
at lint.

The one offender, EdgePanManager.test.ts, was migrated to drive through
TestEditor. It previously stubbed requestAnimationFrame and asserted on
handlePointerMove spy args. The rewrite starts a real hand-tool drag,
triggers updateEdgePan, and asserts on the observable viewport pan change.
Two tests (pans during drag, no-op when not dragging). No rAF stub, no
spy, no vi.fn.
File tested `Selection` from `@/types/selection` but lived in `managers/`
under a name implying a `SelectionManager` class that doesn't exist.
Moves next to the source, matches the class it actually covers. Pure
rename — no content changes.

Per testing-strategy.md scope item #4.
Cleans up the five remaining legacy offenders of the /writing-tests
skill's banned patterns. All now use closure capture (payload arrays,
flags) or closure counters instead of vi.fn / vi.spyOn / toHaveBeenCalled.

- lifecycle.test.ts — EventEmitter tests capture payloads in arrays.
- signal.test.ts — reactive library tests use closure counters (`let
  fires = 0; ...; fires++`). Count IS the library's contract.
- BaseTool.test.ts (renamed from BaseTool.contract.test.ts) — lifecycle
  contract tests capture every (prev, next, event) triple in an array.
- layout.test.ts — the three bbox-optimization assertions use closure
  counters. The createMockFont parallel-world hack is flagged via
  comment and tracked in projects/shift/text-layout-rethink.md.
- KeyboardRouter.test.ts — full rewrite through TestEditor. Drives
  through a real editor and asserts on observable state: editor.zoom,
  editor.getActiveTool(), editor.pointCount, editor.clipboardBuffer,
  editor.toolManager.activeToolId.

Broader oxlint rule shift/no-vitest-mock-primitives fires on vi.fn,
vi.spyOn, toHaveBeenCalled*, and .mock.calls in .test.ts files. Zero
exemptions — all migrations landed.

Skill updated: the banned-patterns row now explicitly lists vitest
primitives and carves out closure counters for primitives whose
contract IS the invocation count.
@kostyafarber kostyafarber force-pushed the firmclaw/writing-tests-skill branch from 997a922 to 410c8a5 Compare April 26, 2026 19:41
@kostyafarber kostyafarber merged commit d7e9925 into main Apr 26, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant